# SPDX-FileCopyrightText: 2019 ash contributors <https://github.com/ash-project/ash/graphs.contributors>
#
# SPDX-License-Identifier: MIT

if Code.ensure_loaded?(Igniter) do
  defmodule Ash.Resource.Igniter do
    @moduledoc """
    Codemods for working with Ash.Resource modules

    ## Important Details

    This interrogates *the source code* of a resource, not its ultimate compiled state.
    What this means, is that things like `defines_attribute` will not return `true` if
    the attribute is added by an extension. Only if it appears literally in the source code
    of the resource or one of its `Spark.Dsl.Fragment`s.
    """

    @doc "List all resource modules found in the project"
    def list_resources(igniter) do
      # If any files have been removed, fall back to scanning all sources
      # since a removed file could have contained a resource definition
      if igniter.rms != [] do
        {igniter, scan_sources_for_resources(igniter, scan_all: true)}
      else
        case get_compiled_resources(igniter) do
          {:ok, compiled_resources} ->
            # Fast path: combine compiled resources with any changed sources
            changed_resources = scan_sources_for_resources(igniter, scan_all: false)
            {igniter, Enum.uniq(compiled_resources ++ changed_resources)}

          :error ->
            # Fallback: scan all sources if we can't get compiled resources
            {igniter, scan_sources_for_resources(igniter, scan_all: true)}
        end
      end
    end

    defp get_compiled_resources(igniter) do
      app_name = Igniter.Project.Application.app_name(igniter)

      resources =
        app_name
        |> Ash.Info.domains_and_resources()
        |> Map.values()
        |> List.flatten()

      {:ok, resources}
    rescue
      _ -> :error
    end

    defp scan_sources_for_resources(igniter, opts) do
      Ash.Igniter.find_all_matching_modules(
        igniter,
        fn _module, zipper ->
          match?({:ok, _}, Igniter.Code.Module.move_to_use(zipper, resource_mods(igniter)))
        end,
        opts
      )
    end

    @doc "Gets the domain from the given resource module"
    @spec domain(Igniter.t(), Ash.Resource.t()) ::
            {:ok, Igniter.t(), Ash.Domain.t()} | {:error, Igniter.t()}
    def domain(igniter, resource) do
      case Igniter.Project.Module.find_module(igniter, resource) do
        {:ok, {igniter, _source, zipper}} ->
          with {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper),
               {:ok, zipper} <-
                 Igniter.Code.Module.move_to_use(zipper, resource_mods(igniter)),
               {:ok, zipper} <-
                 Igniter.Code.Function.move_to_nth_argument(zipper, 1),
               {:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :domain),
               module when not is_nil(module) <-
                 Igniter.Project.Module.to_module_name(zipper.node) do
            {:ok, igniter, module}
          else
            _ ->
              {:error, igniter}
          end

        {:error, igniter} ->
          {:error, igniter}
      end
    end

    def resource_mods(igniter) do
      app_name = Igniter.Project.Application.app_name(igniter)

      [Ash.Resource | List.wrap(Application.get_env(app_name, :base_resources))]
    end

    @doc "Adds the given code block to the block of the resource specified"
    def add_block(igniter, resource, block, chunk) do
      Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
        with {:ok, zipper} <-
               Igniter.Code.Function.move_to_function_call_in_current_scope(
                 zipper,
                 block,
                 1
               ),
             {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do
          {:ok, Igniter.Code.Common.add_code(zipper, chunk)}
        else
          _ ->
            block_with_chunk = """
            #{block} do
              #{chunk}
            end
            """

            {:ok, Igniter.Code.Common.add_code(zipper, block_with_chunk)}
        end
      end)
    end

    @doc "Adds a bypass to the top of the resource's `policies` block"
    def add_bypass(igniter, resource, condition, body) do
      Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
        with {:ok, zipper} <-
               Igniter.Code.Function.move_to_function_call_in_current_scope(
                 zipper,
                 :policies,
                 1
               ),
             {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do
          bypass =
            quote do
              bypass unquote(condition) do
                unquote(body)
              end
            end
            |> Sourceror.to_string()
            |> Sourceror.parse_string!()

          {:ok, Igniter.Code.Common.add_code(zipper, bypass, placement: :before)}
        else
          _ ->
            bypass =
              quote do
                policies do
                  bypass unquote(condition) do
                    unquote(body)
                  end
                end
              end
              |> Sourceror.to_string()
              |> Sourceror.parse_string!()

            {:ok, Igniter.Code.Common.add_code(zipper, bypass)}
        end
      end)
    end

    @doc "Adds a policy to the bottom of the resource's `policies` block"
    def add_policy(igniter, resource, condition, body) do
      Igniter.Project.Module.find_and_update_module!(igniter, resource, fn zipper ->
        with {:ok, zipper} <-
               Igniter.Code.Function.move_to_function_call_in_current_scope(
                 zipper,
                 :policies,
                 1
               ),
             {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper) do
          policy =
            quote do
              policy unquote(condition) do
                unquote(body)
              end
            end
            |> Sourceror.to_string()
            |> Sourceror.parse_string!()

          {:ok, Igniter.Code.Common.add_code(zipper, policy, placement: :after)}
        else
          _ ->
            policy =
              quote do
                policies do
                  policy unquote(condition) do
                    unquote(body)
                  end
                end
              end
              |> Sourceror.to_string()
              |> Sourceror.parse_string!()

            {:ok, Igniter.Code.Common.add_code(zipper, policy)}
        end
      end)
    end

    @doc "Returns true if the given resource defines an attribute with the provided name"
    @spec defines_attribute(Igniter.t(), Ash.Resource.t(), atom()) :: {Igniter.t(), true | false}
    def defines_attribute(igniter, resource, name) do
      {igniter, defines?} =
        if name in [:inserted_at, :updated_at] do
          has_timestamps_call(igniter, resource)
        else
          {igniter, false}
        end

      if defines? do
        {igniter, true}
      else
        Spark.Igniter.find(igniter, resource, fn _, zipper ->
          with {:ok, zipper} <- enter_section(zipper, :attributes),
               {:ok, _zipper} <-
                 move_to_one_of_function_call_in_current_scope(
                   zipper,
                   [
                     :attribute,
                     :uuid_primary_key,
                     :integer_primary_key,
                     :uuid_v7_primary_key,
                     :create_timestamp,
                     :update_timestamp
                   ],
                   [1, 2, 3],
                   &Igniter.Code.Function.argument_equals?(&1, 0, name)
                 ) do
            {:ok, true}
          else
            _ ->
              :error
          end
        end)
        |> case do
          {:ok, igniter, _module, _value} ->
            {igniter, true}

          {:error, igniter} ->
            {igniter, false}
        end
      end
    end

    @doc "Returns true if the given resource defines a calculation with the provided name"
    @spec defines_calculation(Igniter.t(), Ash.Resource.t(), atom()) ::
            {Igniter.t(), true | false}
    def defines_calculation(igniter, resource, name) do
      Spark.Igniter.find(igniter, resource, fn _, zipper ->
        with {:ok, zipper} <- enter_section(zipper, :calculations),
             {:ok, _zipper} <-
               move_to_one_of_function_call_in_current_scope(
                 zipper,
                 [
                   :calculate
                 ],
                 [3],
                 &Igniter.Code.Function.argument_equals?(&1, 0, name)
               ) do
          {:ok, true}
        else
          _ ->
            :error
        end
      end)
      |> case do
        {:ok, igniter, _module, _value} ->
          {igniter, true}

        {:error, igniter} ->
          {igniter, false}
      end
    end

    @doc "Returns true if the given resource defines an identity with the provided name"
    @spec defines_identity(Igniter.t(), Ash.Resource.t(), atom()) :: {Igniter.t(), true | false}
    def defines_identity(igniter, resource, name) do
      Spark.Igniter.find(igniter, resource, fn _, zipper ->
        with {:ok, zipper} <- enter_section(zipper, :identities),
             {:ok, _zipper} <-
               move_to_one_of_function_call_in_current_scope(
                 zipper,
                 [:identity],
                 [2, 3],
                 &Igniter.Code.Function.argument_equals?(&1, 0, name)
               ) do
          {:ok, true}
        else
          _ ->
            :error
        end
      end)
      |> case do
        {:ok, igniter, _module, _value} ->
          {igniter, true}

        {:error, igniter} ->
          {igniter, false}
      end
    end

    @doc "Returns true if the given resource defines an action with the provided name"
    @spec defines_action(Igniter.t(), Ash.Resource.t(), atom()) :: {Igniter.t(), true | false}
    def defines_action(igniter, resource, name) do
      {igniter, defines?} =
        if name in [:create, :read, :update, :destroy] do
          has_default_action(igniter, resource, name)
        else
          {igniter, false}
        end

      if defines? do
        {igniter, true}
      else
        Spark.Igniter.find(igniter, resource, fn _, zipper ->
          with {:ok, zipper} <- enter_section(zipper, :actions),
               {:ok, _zipper} <-
                 move_to_one_of_function_call_in_current_scope(
                   zipper,
                   [
                     :create,
                     :update,
                     :read,
                     :destroy,
                     :action
                   ],
                   [2, 3, 4],
                   &Igniter.Code.Function.argument_equals?(&1, 0, name)
                 ) do
            {:ok, true}
          else
            _ ->
              :error
          end
        end)
        |> case do
          {:ok, igniter, _module, _value} ->
            {igniter, true}

          {:error, igniter} ->
            {igniter, false}
        end
      end
    end

    @spec ensure_primary_action(
            Igniter.t(),
            Ash.Resource.t(),
            :create | :read | :update | :destroy
          ) ::
            Igniter.t()
    def ensure_primary_action(igniter, resource, type) do
      case has_default_action(igniter, resource, type) do
        {igniter, true} ->
          igniter

        {igniter, false} ->
          case has_action_with_primary(igniter, resource, type) do
            {igniter, true} -> igniter
            {igniter, false} -> add_default_action(igniter, resource, type)
          end
      end
    end

    @doc "Returns true if the given resource defines a relationship with the provided name"
    @spec defines_relationship(Igniter.t(), Ash.Resource.t(), atom()) ::
            {Igniter.t(), true | false}
    def defines_relationship(igniter, resource, name) do
      Spark.Igniter.find(igniter, resource, fn _, zipper ->
        with {:ok, zipper} <- enter_section(zipper, :relationships),
             {:ok, _zipper} <-
               move_to_one_of_function_call_in_current_scope(
                 zipper,
                 [
                   :has_one,
                   :has_many,
                   :belongs_to,
                   :many_to_many
                 ],
                 [2, 3],
                 &Igniter.Code.Function.argument_equals?(&1, 0, name)
               ) do
          {:ok, true}
        else
          _ ->
            :error
        end
      end)
      |> case do
        {:ok, igniter, _module, _value} ->
          {igniter, true}

        {:error, igniter} ->
          {igniter, false}
      end
    end

    @doc "Adds the given code block to the resource's `attributes` block if there is no existing attribute with the given name"
    def add_new_attribute(igniter, resource, name, attribute) do
      {igniter, defines?} = defines_attribute(igniter, resource, name)

      if defines? do
        igniter
      else
        add_attribute(igniter, resource, attribute)
      end
    end

    @doc "Adds the given code block to the resource's `calculations` block if there is no existing calculation with the given name"
    def add_new_calculation(igniter, resource, name, attribute) do
      {igniter, defines?} = defines_calculation(igniter, resource, name)

      if defines? do
        igniter
      else
        add_calculation(igniter, resource, attribute)
      end
    end

    @doc "Adds the given code block to the resource's `identities` block if there is no existing identity with the given name"
    def add_new_identity(igniter, resource, name, identity) do
      {igniter, defines?} = defines_identity(igniter, resource, name)

      if defines? do
        igniter
      else
        add_identity(igniter, resource, identity)
      end
    end

    @doc "Adds the given code block to the resource's `identities` block"
    def add_identity(igniter, resource, identity) do
      add_block(igniter, resource, :identities, identity)
    end

    @doc "Adds the given code block to the resource's `attributes` block"
    def add_attribute(igniter, resource, attribute) do
      add_block(igniter, resource, :attributes, attribute)
    end

    @doc "Adds the given code block to the resource's `calculations` block"
    def add_calculation(igniter, resource, calculation) do
      add_block(igniter, resource, :calculations, calculation)
    end

    @doc "Adds an action if it doesn't already exist"
    def add_new_action(igniter, resource, name, action) do
      {igniter, defines?} = defines_action(igniter, resource, name)

      if defines? do
        igniter
      else
        add_action(igniter, resource, action)
      end
    end

    @doc "Adds the given code block to the resource's `actions` block"
    def add_action(igniter, resource, action) do
      add_block(igniter, resource, :actions, action)
    end

    @doc "Adds the given code block to the resource's `relationships` block"
    def add_new_relationship(igniter, resource, name, relationship) do
      {igniter, defines?} = defines_relationship(igniter, resource, name)

      if defines? do
        igniter
      else
        add_block(igniter, resource, :relationships, relationship)
      end
    end

    @doc "Adds the given code block to the resource's `relationships` block"
    def add_relationship(igniter, resource, relationship) do
      add_block(igniter, resource, :relationships, relationship)
    end

    @doc "Adds the given code block to the resource's `resource` block"
    def add_resource_configuration(igniter, resource, resource_configuration) do
      add_block(igniter, resource, :resource, resource_configuration)
    end

    @doc "Ensures that created_at and updated_at timestamps exist on the resource"
    def ensure_timestamps(igniter, resource) do
      {igniter, defines_inserted_at?} = defines_attribute(igniter, resource, :inserted_at)
      {igniter, defines_created_at?} = defines_attribute(igniter, resource, :created_at)
      defines_create_timestamp? = defines_inserted_at? || defines_created_at?
      {igniter, defines_updated_at?} = defines_attribute(igniter, resource, :updated_at)

      igniter
      |> then(fn igniter ->
        if defines_create_timestamp? do
          igniter
        else
          add_create_timestamp_call(igniter, resource)
        end
      end)
      |> then(fn igniter ->
        if defines_updated_at? do
          igniter
        else
          add_update_timestamp_call(igniter, resource)
        end
      end)
    end

    defp enter_section(zipper, name) do
      with {:ok, zipper} <-
             Igniter.Code.Function.move_to_function_call_in_current_scope(
               zipper,
               name,
               1
             ) do
        Igniter.Code.Common.move_to_do_block(zipper)
      end
    end

    defp move_to_one_of_function_call_in_current_scope(zipper, [name], arities, pred) do
      Igniter.Code.Function.move_to_function_call_in_current_scope(
        zipper,
        name,
        arities,
        pred
      )
    end

    defp move_to_one_of_function_call_in_current_scope(zipper, [name | rest], arities, pred) do
      case Igniter.Code.Function.move_to_function_call_in_current_scope(
             zipper,
             name,
             arities,
             pred
           ) do
        {:ok, zipper} -> {:ok, zipper}
        :error -> move_to_one_of_function_call_in_current_scope(zipper, rest, arities, pred)
      end
    end

    defp has_default_action(igniter, resource, type) do
      Spark.Igniter.find(igniter, resource, fn _, zipper ->
        with {:ok, zipper} <- enter_section(zipper, :actions),
             {:ok, _zipper} <-
               Igniter.Code.Function.move_to_function_call_in_current_scope(
                 zipper,
                 :defaults,
                 1,
                 fn zipper ->
                   with {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 0),
                        {:ok, _zipper} <-
                          Igniter.Code.List.move_to_list_item(zipper, fn zipper ->
                            if Igniter.Code.Tuple.tuple?(zipper) do
                              case Igniter.Code.Tuple.tuple_elem(zipper, 0) do
                                {:ok, zipper} ->
                                  Igniter.Code.Common.nodes_equal?(zipper, type)

                                _ ->
                                  false
                              end
                            else
                              Igniter.Code.Common.nodes_equal?(zipper, type)
                            end
                          end) do
                     true
                   else
                     _ -> false
                   end
                 end
               ) do
          {:ok, true}
        else
          _ -> :error
        end
      end)
      |> case do
        {:ok, igniter, _module, _value} ->
          {igniter, true}

        {:error, igniter} ->
          {igniter, false}
      end
    end

    @spec has_action_with_primary(Igniter.t(), Ash.Resource.t(), atom()) ::
            {Igniter.t(), true | false}
    def has_action_with_primary(igniter, resource, type) do
      Spark.Igniter.find(igniter, resource, fn _, zipper ->
        with {:ok, zipper} <- enter_section(zipper, :actions),
             {:ok, _zipper} <-
               Igniter.Code.Function.move_to_function_call_in_current_scope(zipper, type, [1, 2]) do
          case Igniter.Code.Common.move_to_do_block(zipper) do
            {:ok, zipper} ->
              Igniter.Code.Function.move_to_function_call_in_current_scope(
                zipper,
                :primary?,
                1,
                &Igniter.Code.Function.argument_equals?(&1, 0, true)
              )

            :error ->
              with {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1),
                   {:ok, zipper} <- Igniter.Code.Keyword.get_key(zipper, :primary?),
                   true <- Igniter.Code.Common.nodes_equal?(zipper, true) do
                {:ok, true}
              end
          end
        end
      end)
      |> case do
        {:ok, igniter, _module, _value} ->
          {igniter, true}

        {:error, igniter} ->
          {igniter, false}
      end
    end

    defp add_default_action(igniter, resource, type) do
      Spark.Igniter.find(igniter, resource, fn _, zipper ->
        with {:ok, zipper} <- enter_section(zipper, :actions),
             {:ok, _zipper} <-
               Igniter.Code.Function.move_to_function_call_in_current_scope(
                 zipper,
                 :defaults,
                 1
               ) do
          {:ok, true}
        else
          _ -> :error
        end
      end)
      |> case do
        {:ok, igniter, module, _value} ->
          Igniter.Project.Module.find_and_update_module!(igniter, module, fn zipper ->
            with {:ok, zipper} <- enter_section(zipper, :actions),
                 {:ok, _zipper} <-
                   Igniter.Code.Function.move_to_function_call_in_current_scope(
                     zipper,
                     :defaults,
                     1
                   ),
                 {:ok, zipper} <- Igniter.Code.List.prepend_new_to_list(zipper, type) do
              {:ok, zipper}
            else
              _ ->
                {:error,
                 "Failed to add a default action of type #{inspect(type)} in #{inspect(module)}"}
            end
          end)

        {:error, igniter} ->
          igniter
      end
    end

    defp add_create_timestamp_call(igniter, resource) do
      add_block(igniter, resource, :attributes, "create_timestamp :created_at")
    end

    defp add_update_timestamp_call(igniter, resource) do
      add_block(igniter, resource, :attributes, "update_timestamp :updated_at")
    end

    defp has_timestamps_call(igniter, resource) do
      Spark.Igniter.find(igniter, resource, fn _, zipper ->
        with {:ok, zipper} <- enter_section(zipper, :attributes),
             {:ok, _zipper} <-
               Igniter.Code.Function.move_to_function_call_in_current_scope(
                 zipper,
                 :timestamps,
                 1
               ) do
          {:ok, true}
        else
          _ -> :error
        end
      end)
      |> case do
        {:ok, igniter, _module, _value} ->
          {igniter, true}

        {:error, igniter} ->
          {igniter, false}
      end
    end
  end
end
